Day 25,我們把 Vibe Guard 包成一條本機指令:
npm run vibe-guard -- scan .
它會執行 Recon、Hunter、Challenger 與動態測試,再輸出:
Canonical Findings Report
Markdown Summary
Policy Gate
Exit Code
開發者可以在提交前主動執行。
但本機檢查有一個無法避免的限制:
它可以被忘記,也可以被跳過。
今天要把相同 Contract 放進 GitHub Pull Request。
每次 PR 建立或更新時,GitHub Actions 會:
Checkout PR Revision
→ 安裝固定相依套件
→ 計算 Base 與 Head 的變更行
→ 執行 Vibe Guard CLI
→ 產生 Job Summary 與 Annotation
→ 保存完整 Artifact
→ 依 Policy 決定 Check 成功或失敗
重點不是在 CI 裡重新做一套 Reviewer。
本機與 Pull Request 必須共用:
同一條掃描流程
同一份 Findings Schema
同一套 Severity Policy
同一組 Exit Code 語意
否則很容易出現:
本機顯示通過,
Pull Request 卻用另一套規則失敗。
Day 25 的資料流是:
Agent Workflow
→ Findings Report
→ Policy Evaluation
→ Terminal / JSON / Markdown
→ Exit Code
Day 26 不改變這條流程。
GitHub Actions 只是增加新的 Consumer:
Findings Report
→ Pull Request Adapter
→ Check Annotation
→ Job Summary
→ Workflow Artifact
→ Required Status Check
Pull Request Adapter 不會:
requires_evidence 自動升級成 High。它只回答:
這項 Finding 是否落在本次變更範圍?
依照 PR Policy,是否應阻擋合併?
應該在 GitHub 顯示成 Error、Warning 還是 Notice?
這是確定性的 Policy 與格式轉換,不需要另一個 LLM。
完整 Repository Scan 可能找到:
今天將它們分類為:
| Scope | 意義 |
|---|---|
changed-line |
Finding Location 與 PR 新增/修改行重疊 |
changed-file |
檔案有變更,但 Finding 行不在 Diff Hunk |
existing |
Finding 位於本次沒有修改的檔案 |
global |
沒有單一 Source Location,例如部署證據缺口 |
第一版 PR Gate 只阻擋:
confirmed
severity >= high
scope = changed-line
其他結果仍然顯示,但不直接把這次 PR 判定失敗。
這是刻意採取的過渡政策。
如果第一天導入工具就要求:
整個 Repository 的所有既有 High Finding 都必須清零,
否則任何 PR 都不能合併。
團隊很可能因為大量既有問題而直接停用 Check。
比較可行的起點是:
先阻止 PR 在修改行引入或重新暴露已確認的 High 問題,
同時把既有問題與 Evidence Gap 保存到 Summary。
不過,changed-line 仍不等於 new。
一項問題可能早已存在,只是這次 PR 剛好修改同一行。
真正的 New、Unchanged、Resolved 與 Reopened,需要 Day 24 的 Fingerprint 搭配 Base Revision Baseline 比較。
今天只使用精確名稱:
changed-line finding
不把它誤稱為新漏洞。
假設 PR 修改:
src/main.js
而 Scanner 在同一個檔案的另一個舊 Function 找到 High Finding。
如果 Policy 只判斷:
changedFiles.includes(location.path)
這項既有問題會被誤認為本次變更造成。
所以 Day 26 需要解析 Unified Diff Hunk。
Git 指令使用:
git diff --unified=0 --no-color \
"$BASE_SHA...$HEAD_SHA" \
-- demo-app
--unified=0 讓輸出只保留真正新增或修改的行,減少 Context Line 對範圍判斷的干擾。
Hunk Header 類似:
@@ -6 +6 @@
表示新版本的第 6 行被修改。
Parser 會追蹤:
const hunk = line.match(
/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
);
並將 + 開頭的新行記錄為:
{
"path": "demo-app/firestore.rules",
"lines": [6]
}
刪除行不存在於 Head Revision,因此不能直接在新的 Source 上建立 Line Annotation。
若 Finding 只存在於已刪除程式碼,通常代表它應該在 Baseline 比較中變成 Resolved,而不是對不存在的行留下 Error。
專案新增:
demo-app/pr-review-demo/
├── collect-diff.js
├── diff.js
├── report.js
├── scenario.js
└── fixtures/
├── event.json
└── pull-request.diff
diff.js 將 Unified Diff 轉成 Changed Line Map:
export function parseUnifiedDiff(diff) {
const files = new Map();
let currentPath;
let newLine = 0;
for (const line of diff.split("\n")) {
if (line.startsWith("+++ ")) {
// Record the new revision path.
}
const hunk = line.match(
/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
);
if (hunk) {
newLine = Number(hunk[1]);
}
if (line.startsWith("+")) {
files.get(currentPath).add(newLine);
newLine += 1;
} else if (!line.startsWith("-")) {
newLine += 1;
}
}
}
正式 Workflow 傳入:
Base SHA
Head SHA
Pathspec
Output Path
node demo-app/pr-review-demo/collect-diff.js \
--base "$BASE_SHA" \
--head "$HEAD_SHA" \
--path demo-app \
--output demo-app/vibe-guard-pr-output/changed-lines.json
Base 與 Head 從 GitHub Event 取得,但不直接插入產生中的 Shell Script。
Workflow 先將它們放入 Environment Variable:
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
再使用:
"$BASE_SHA"
"$HEAD_SHA"
GitHub 的 Secure Use 文件建議,處理可能不可信的 Context Value 時,使用 Intermediate Environment Variable,而不是直接拼進 Inline Script。
Adapter 讀取三份資料:
vibe-guard-pr-output/findings-report.json
vibe-guard-pr-output/changed-lines.json
GITHUB_EVENT_PATH
第一份是 Day 25 CLI 產生的 Canonical Report。
第二份是本次 PR 的 Diff Scope。
第三份提供 PR Number、Base SHA 與 Head SHA。
每項 Finding 先轉成 Repository Path:
const repositoryPath =
`demo-app/${location.path}`;
再判斷 Location 是否與 Changed Line 重疊:
const changed = [...lines].some(
(line) =>
line >= location.startLine &&
line <= location.endLine
);
Blocking Rule 為:
const blocking =
finding.verdict === "confirmed" &&
scope === "changed-line" &&
severityBlocks(finding.severity, "high");
注意 Policy 不會修改原 Finding。
AUTH-01 仍然是:
confirmed / open / high
PR Adapter 只額外記錄:
scope = changed-line
gate = block
GitHub Actions 支援 Workflow Command:
::error file=app.js,line=1::Message
它會在 Check 中建立與檔案、行號關聯的 Annotation。
Day 26 依結果選擇:
| Finding | Annotation |
|---|---|
| Blocking Confirmed Finding | error |
| Requires Evidence | warning |
| 非阻擋 Confirmed Finding | notice |
| Rejected Candidate | 不建立 Source Annotation |
AUTH-01 會產生概念上類似:
::error file=demo-app/firestore.rules,line=6,title=Vibe Guard AUTH-01::Any signed-in user can access another user's project
Annotation 內容可能包含:
百分比符號
換行
逗號
冒號
Repository 中的不可信文字
不能直接拼接。
Demo 先做 Workflow Command Escape:
function escapeWorkflowCommand(value) {
return value
.replaceAll("%", "%25")
.replaceAll("\r", "%0D")
.replaceAll("\n", "%0A")
.replaceAll(":", "%3A")
.replaceAll(",", "%2C");
}
更完整的產品可以使用 @actions/core 的 Annotation API,減少自行處理 Workflow Command Encoding。
無論使用哪種方式,都不能把模型或 Repository 產生的任意字串當成可信 Runner Command。
Annotation 適合回答:
哪個檔案、哪一行有問題?
Job Summary 適合回答:
這次掃描整體是否通過?
有哪些 Confirmed、Rejected 與 Requires Evidence?
哪些 Finding 位於本次 Diff?
缺少哪些正式環境證據?
Adapter 會把 Markdown 寫入:
vibe-guard-pr-output/pr-summary.md
若環境存在:
GITHUB_STEP_SUMMARY
也會將同一份內容附加到 GitHub Job Summary。
GitHub Actions 官方文件將 GITHUB_STEP_SUMMARY 定義為建立每個 Job 摘要的 Environment File。
因此不需要呼叫 GitHub API,也不需要給 Workflow pull-requests: write。
這是今天刻意選擇的安全預設。
PR Comment 很方便,但它需要 Write Permission。
如果 Workflow:
惡意 PR 就可能修改 Script,竊取或濫用該 Token。
今天的 Workflow 使用:
permissions:
contents: read
它只需要讀取 Checkout 的程式碼。
結果透過:
Check Status
Annotation
Job Summary
Artifact
回到 GitHub,不需要 PR Write Permission。
若未來一定要更新 Sticky Comment,可以拆成兩個 Workflow:
Untrusted Scan Workflow
pull_request
read-only token
no secrets
upload sanitized artifact
Trusted Publisher Workflow
workflow_run
write permission
never checkout or execute PR code
validate and sanitize downloaded artifact
publish comment
但 workflow_run Artifact 仍是不可信輸入。
Publisher 只能解析固定 Schema、限制大小與文字內容,不能執行 Artifact 中的任何檔案。
絕對不要為了取得 Secrets 或 Write Permission,改用:
pull_request_target:
然後 Checkout 並執行 PR Head。
GitHub 的 Secure Use 文件明確提醒,pull_request_target 與 workflow_run 若搭配不可信程式碼 Checkout,可能讓攻擊者取得 Repository Write Access 或 Secrets。
Workflow 放在:
.github/workflows/vibe-guard-pr.yml
觸發條件:
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- "demo-app/**"
- ".github/workflows/vibe-guard-pr.yml"
synchronize 會在 PR 推送新 Commit 時重新執行。
ready_for_review 則讓 Draft PR 轉成正式 Review 時開始建立 Gate。
Job 另外排除仍在 Draft 的 PR:
if: github.event.pull_request.draft == false
這項選擇取決於團隊。
如果希望 Draft 階段就提早回饋,可以移除這個條件,但不把 Check 設為 Required。
AI Review 與 Emulator Test 比一般 Lint 慢。
如果開發者連續推送三個 Commit,舊 Run 繼續執行通常沒有價值。
Workflow 加入:
concurrency:
group: vibe-guard-${{ github.event.pull_request.number }}
cancel-in-progress: true
同一個 PR 只保留最新 Run。
不同 PR 使用不同 Group,因此可以平行執行。
取消舊 Run 不應刪除已完成的歷史 Artifact,但進行中的舊 Revision 不再消耗 Runner 與 API Quota。
Diff Collector 要比較 Base 與 Head:
- name: Checkout pull request
uses: actions/checkout@v6
with:
fetch-depth: 0
預設 Shallow Checkout 可能沒有 Base Commit。
fetch-depth: 0 取得完整歷史,讓:
git diff "$BASE_SHA...$HEAD_SHA"
可以穩定執行。
代價是大型 Repository Checkout 會變慢。
正式 Monorepo 可以改成只 Fetch 需要的 Base 與 Head,但必須處理 Merge Base 不存在或 Force Push 等情況。
第一版先使用較容易驗證的完整歷史。
Workflow 使用:
- name: Install dependencies
working-directory: demo-app
run: npm ci
不用:
npm install
npm ci 會依照 Lockfile 建立相依套件,且不修改 Manifest。
Node Version 也固定:
node-version: 24
這能降低:
本機與 CI Runtime 不一致
最新 Node 改變語意
Lockfile 被隱性更新
正式環境還應將第三方 Action Pin 到完整 Commit SHA。
GitHub 官方安全指南指出,完整 Commit SHA 是將 Action 固定為不可變版本的方式。
文章 Demo 使用 Major Tag 方便閱讀:
actions/checkout@v6
actions/setup-node@v6
actions/upload-artifact@v4
導入真實 Repository 前,應把經審查的版本換成完整 SHA,並使用 Dependabot 或 Renovate 管理更新。
Day 25 定義:
| Exit Code | 意義 |
|---|---|
0 |
Scan 完成,Local Policy 通過 |
1 |
Scan 完成,Finding Policy 不通過 |
2 |
Scan、Artifact 或 Schema 發生錯誤 |
如果 Workflow 直接執行:
- run: npm run vibe-guard -- scan .
遇到 Exit Code 1 時,後面的 Summary 與 Artifact Upload 可能不會執行。
開發者只看到紅色叉號,卻拿不到完整報告。
今天先捕捉 Exit Code:
set +e
npm run vibe-guard -- scan . \
--output vibe-guard-pr-output \
--format markdown \
--fail-on high \
--evidence-policy warn
code=$?
echo "exit_code=$code" >> "$GITHUB_OUTPUT"
exit 0
這不是吞掉失敗。
它只是延後裁決,讓 Workflow 還能:
建立 Annotation
寫入 Job Summary
上傳 Artifact
區分 Policy Fail 與 Execution Error
最後的 Enforce Step 才決定 Job 結果:
if [ "$SCAN_EXIT_CODE" = "2" ]; then
exit 2
fi
if [ "$PR_GATE" = "fail" ]; then
exit 1
fi
這裡再次保留:
Finding Fail != Tool Error
Workflow 使用:
- name: Upload review artifacts
if: always()
uses: actions/upload-artifact@v4
if: always() 確保前面 Finding Gate 不通過時,仍會嘗試保存:
findings-report.json
summary.md
changed-lines.json
pr-summary.md
annotations.json
Artifact 設定:
retention-days: 14
if-no-files-found: warn
Retention 需要配合 Source Code 與安全報告的敏感度。
Artifact 可能包含:
Source Snippet
路徑
弱點描述
測試結果
部署證據缺口
不能因為它由 CI 產生,就假設適合永久保存或提供所有人下載。
GitHub Artifact 上傳也會產生 SHA-256 Digest;下載時可以驗證內容是否與上傳 Artifact 一致。
不過 Digest 只能證明傳輸後內容一致,不能證明 Artifact 本身是可信的。
在沒有實際 GitHub Repository 的情況下,Demo 使用一份 Pull Request Event 與 Unified Diff Fixture。
執行:
cd /media/mickey/777/ithome/demo-app
npm run pr-review:demo
Fixture 模擬兩項變更。
第一項將安全 Rules:
request.auth.uid == resource.data.ownerId
改成:
request.auth != null
第二項將 Owner-filtered Query 改成整個 Collection:
query(collection(db, "projects"))
PR Adapter 取得:
demo-app/firestore.rules:6
demo-app/src/main.js:143
因此 AUTH-01 的 Primary Location:
firestore.rules:6
會被判斷為:
changed-line
實際 Summary:
# Vibe Guard Pull Request Review
- Pull request: #26
- Base: 1111111111111111111111111111111111111111
- Head: 2222222222222222222222222222222222222222
- Changed files: 2
- Gate: FAIL
| ID | Verdict | Severity | Diff scope | Gate | Location |
|---|---|---|---|---|---|
| AUTH-01 | confirmed | high | changed-line | BLOCK | firestore.rules:6 |
| SECRET-01 | rejected | - | global | report | - |
| RATE-01 | requires_evidence | - | global | report | - |
AUTH-01 同時滿足:
confirmed
high
changed-line
因此阻擋。
SECRET-01 是 Rejected Candidate,只保存於 Summary 與 Artifact,不建立修正警告。
RATE-01 沒有單一 Source Location,屬於 Global Evidence Gap。第一版 Policy 會顯示 Missing Evidence,但不把它錯誤定位到某一行。
Workflow Failure 本身不一定阻止 Pull Request 合併。
Repository 還要在 Ruleset 或 Branch Protection 中,將:
Production readiness gate
設成 Required Status Check。
設定時要注意:
若 Check Name 經常修改:
Vibe Guard
Vibe Guard Review
Production Readiness
Branch Rule 可能仍等待已不存在的舊 Check,或新 Check 根本沒有被設為 Required。
今天固定 Job Name:
name: Production readiness gate
只把 Unified Diff 交給 Agent 速度很快,但容易缺少:
被呼叫 Function
資料模型
Security Rules
既有 Mitigation
跨檔案 Data Flow
部署設定
測試
Day 21 已說明 Context 必須從 Rule 與架構反推。
所以今天的策略是:
完整 Repository 用於分析與驗證
Pull Request Diff 用於 Scope 與 Gate Policy
這兩者不能交換。
Repository Context 回答:
Finding 是否成立?
Diff Scope 回答:
這項 Finding 與本次變更的關係是什麼?
如果只掃 Diff,AUTH-01 可能只看到:
request.auth != null
卻沒有看到 ownerId、Client Query 與雙帳號測試。
如果完全忽略 Diff,則任何無關 PR 都可能被既有問題阻擋。
三者用途不同。
| Channel | 適合內容 |
|---|---|
| Job Summary | 本次 Run、Gate、統計、Evidence Gap |
| Check Annotation | 與檔案及行號直接相關的少量高信號結果 |
| PR Comment | 需要對話、狀態更新或無 Source Location 的摘要 |
| SARIF / Code Scanning | 跨 Run 的 Alert、Fingerprint、Baseline 與安全工作流 |
| Artifact | 完整 Canonical Report、驗證紀錄與除錯資料 |
第一版使用 Summary、Annotation 與 Artifact。
Day 24 已建立 Fingerprint,因此後續可以新增 SARIF Exporter,再使用:
github/codeql-action/upload-sarif
將第三方掃描結果匯入 GitHub Code Scanning。
GitHub 文件指出,SARIF 可以透過 partialFingerprints 減少重複 Alert;多份分析則應使用不同 Category。
但 SARIF Upload 需要 security-events: write,私人 Repository 也需要啟用對應的 GitHub Code Security 功能。
在完成 SARIF Schema Mapping 以前,不應把不完整 JSON 改名成 .sarif 上傳。
這個 Workflow 會執行 Pull Request 中的:
package.json Script
JavaScript Scanner
Firebase Test
相依套件程式碼
所以應把整個 Job 視為執行不可信程式碼。
今天採用:
pull_request Trigger。contents: read 的最小 Token 權限。pull_request_target Checkout PR Head。正式 Agent 若需要 GEMINI_API_KEY,Fork PR 會遇到另一個問題:
不應把 API Secret 暴露給不可信 Fork。
可選策略包括:
不能因為 AI Scan 需要 Credential,就把長期 API Key 直接開放給所有 PR。
如果一個 PR 產生 200 個 Annotation,Reviewer 很可能不再閱讀。
第一版可以設定:
最多 10 個 Error Annotation
最多 20 個 Warning
其餘只放 Job Summary 與 Artifact
排序依據:
Blocking
Severity
Confidence
Diff Proximity
Impact
同一 Root Cause 的多個 Location 也應合併。
例如同一條 Firestore Rule 造成十個 Query 都能越權,不應在每個 Client Call Site 留下一模一樣的 High Annotation。
Primary Location 可以指向授權 Root Cause:
firestore.rules:6
其他 Data Flow 放在 Finding Detail 與 Artifact。
今天 Demo 只有一個 Blocking Annotation,因此尚未實作上限,但正式版本必須加入。
PR 每次 Push 都會重新執行。
如果每次都建立新的 Comment:
Vibe Guard Result 1
Vibe Guard Result 2
Vibe Guard Result 3
Conversation 很快會被 Bot 洗版。
Job Summary 不會有這個問題,因為每個 Workflow Run 有自己的摘要。
若未來加入 Comment,應使用 Marker 更新同一則 Sticky Comment:
<!-- vibe-guard-pr-summary -->
並保存:
Run ID
Head SHA
更新時間
Gate
Artifact Link
Publisher 必須確認 Comment 對應目前 Head SHA,避免舊 Run 在較晚完成後覆蓋新結果。
今天的 concurrency.cancel-in-progress 已先降低這種 Race Condition。
今天完成的是可重現的 PR Gate 與安全的唯讀回報。
正式版本還需要:
.github/workflows/** 啟用 CodeQL 或其他 Workflow Security Scan。目前 Demo 會執行完整 Repository Scan,再以 Diff Scope 決定 Gate。
當 Repository 變大後,可以先用 Diff 找出受影響元件,再由 Context Selector 向外擴張必要依賴,而不是永遠全量掃描。
但不能只保留 Diff,否則會回到缺少跨檔案 Context 的問題。
把 Agent 放進 Pull Request,不只是新增一份 YAML。
一個可信的 PR Review Workflow 至少要定義:
今天建立:
.github/workflows/vibe-guard-pr.yml
demo-app/pr-review-demo/
本機 Fixture 模擬 PR 將 Owner Check 改成:
request.auth != null
Vibe Guard 重新執行完整的對抗驗證後,確認:
AUTH-01
confirmed / high / changed-line
因此建立 Error Annotation,並讓:
Production readiness gate
失敗。
被拒絕的 SECRET-01 與缺少部署證據的 RATE-01 仍保留在 Summary,但不會被改寫成這次 PR 的新 High Finding。
這讓 Vibe Guard 從開發者主動執行的工具,進一步成為每次變更都會經過的自動化上線守門員。
明天,我們會替 AI 稽核建立正式考試:使用標註資料集計算檢出率、誤報率與漏報率,而不是只展示幾個成功案例。